Skip to content

feat(devx): a docs gate binds each CLI transcript in content/docs to the registry it quotes, so a derived count cannot drift silently - #15651

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-15373-docs-transcript-count-gate
Sep 5, 2026
Merged

feat(devx): a docs gate binds each CLI transcript in content/docs to the registry it quotes, so a derived count cannot drift silently#15651
baozhoutao merged 1 commit into
mainfrom
claude/issue-15373-docs-transcript-count-gate

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15373

A written number that nothing derives and nothing compares. content/docs teaches with pasted CLI output, and some of those lines quote a value the CLI computes from a live registry at runtime:

packages/cli/src/commands/validate.ts:245
  const registered = authoringRulesFor('validate');
  printStep(`Running author-time rules (${registered.length})...`);

The page wrote it as a literal, so it drifted on every rule added, silently. PR #15369 corrected the four stale numbers; this is the mechanism half.

1. How a transcript block says what it is a transcript OF — measured, not chosen

Both directions in the card had to answer this first. It was answered by compiling every candidate form through the real pipeline — mdxPreset({}) from fumadocs-core/content/mdx/preset-bundler, which is what defineConfig in apps/docs/source.config.ts resolves to (fumadocs-mdx 15.2.3 / fumadocs-core 16.14.4 / @mdx-js/mdx 3.1.1), over the exact transcript body these pages carry:

form result
A. bare ``` fence (what the pages had) COMPILES
B. ``` + transcript=os-validate on a bare fence COMPILE ERRORShikiError: Language transcript=os-validate not found, you may need to load it first
C. ```text transcript=os-validate COMPILES, meta absent from output
D. an HTML comment before the fence COMPILE ERROR — MDX: Unexpected character `!` (U+0021) before name, expected a character that can start a name … (note: to create a comment in MDX, use a JSX comment)
E. a JSX {/* … */} comment before the fence COMPILES, but the text survives into the compiled module as a JS comment

and two equalities on the same pipeline:

bare ``` vs ```text                     IDENTICAL compiled output
```text vs ```text + transcript meta    IDENTICAL compiled output

Chosen: fence meta on a fence that already names a language```text transcript=os-validate.

Then the same measurement on the four real pages, before (origin/main) vs after (this branch), whole file each:

content/docs/ui/react-pages.mdx                    before COMPILES: true  after COMPILES: true  compiled output identical: true  declaration present in output: false
content/docs/getting-started/build-with-claude-code.mdx  before: true  after: true  identical: true  present in output: false
content/docs/deployment/validating-metadata.mdx    before: true  after: true  identical: true  present in output: false
content/docs/deployment/cli.mdx                    before: true  after: true  identical: true  present in output: false

The declaration is free at both ends: byte-identical compiled output, and the meta never reaches it.

Two consequences worth stating, because both read like the obvious choice:

  • The HTML-comment form does not exist in this corpus. MDX rejects HTML comments outright, at parse time, on every page. Measured rather than assumed.
  • Result B is why the gate ships a no-language finding. A transcript= token in first position is read by shiki as the language name and breaks the docs build. That is the one way to write this declaration that turns a documentation-accuracy gate into an outage, so the gate refuses it where the failure is a line number instead of a red next build.

2. Why the kind is per COMMAND

The obvious design is one kind, os-validate. It is wrong on this very corpus: two of the four sites are os validate output and deployment/cli.mdx (under ◆ Compile) is os build output. Measured on d30ccb9bd against the built registry:

authoringRulesFor('validate').length   42
authoringRulesFor('build').length      42
authoringRulesFor('lint').length       39

Two agree today and the third already disagrees, so a single kind would pin an os lint transcript to a number that is wrong right now, and one rule scoped commands: ['validate'] would split the other two. The vocabulary stays tiny: the kinds ARE the registry's own AuthoringCommand union, one row each; a second quoted VALUE is a row in TOKENS, not a new kind.

3. Why not check:corpus-claim-drift — evaluated against its model

Its header is the authority. It is a shrink-only lexical co-occurrence ratchet: does an operator's spelling appear within an N-line window of phrasing naming a semantic the platform does not implement, with a per-file baseline budget. Every part of that model is words near other words.

What is pinned here is an equality between a decimal literal on a page and an integer a module exports at runtime. There is no phrase to co-occur with, no window to widen, and a baseline row would be exactly wrong — a budget for a count that is simply either right or wrong. Folding in would have meant a second, numeric model inside a lexical gate, keyed on a registry import that gate deliberately does not have (it is dependency-free by design). Two mechanisms, one corpus.

4. Why COMPARE and not STAMP

check:docs-image-tag stamps, and #15332 was ruled to stamp, and both are right for what they carry: a single generated token whose only true value is the one the release process just produced. A transcript is not that — it is a hand-authored teaching artifact, elided and annotated, and a stamper would have to own the whole block to own one number inside it. Rewriting prose into a customer-facing page from a script is a much larger claim than checking one integer inside it, and it removes the author's ability to elide. So: compare, name the page and line, let a human type the digit.

5. Where the step lives, and why NOT next to the other docs gates

The gate derives the number the way the CLI does — importing the BUILT @objectstack/lint through that package's own exports/main — so it needs a dist/. Measured on the workflows:

  • lint.yml's Lint & Repo Gates, which hosts every other docs gate, runs pnpm install and never builds (grepped: no build step anywhere in that job). Placed there the gate would exit 3 on every CI run forever — a gate that never measures.
  • ci.yml's Build Core builds and hosts check:dual-build-cjs-loads for exactly this reason, but it is gated on the core paths filter, which does not include content/** — so it would skip the docs-only PRs that ADD a stale transcript.
  • lint.yml's Type Check · workspace has no paths filter, sits behind the required TypeScript Type Check context, and already runs turbo run build --filter='./packages/*', which includes packages/lint.

So the step sits in that lane, immediately after Build workspace packages. It adds no build and no CI minutes. With no dist/ it exits 3 (PREREQUISITE NOT MET), never 1 and never a silent 0.

Scope boundary: content/docs/** only. docs/audits/** carries the same lines with 41 in them and is deliberately out of population — a dated audit record is a historical reading, and correcting it would be falsifying it.

6. Ablations — both directions, each mutation confirmed on disk, each restore confirmed on disk

Run from the committed implementation (8aecfb84b), with an EXIT INT TERM trap holding absolute paths, hashes compared against the HEAD blob on both legs. No rebuild leg is needed: the mutated subject is the .mdx page read straight from disk, not a dist/ artifact.

ABLATION 1 — a printed count drifts (42 -> 41), content/docs/ui/react-pages.mdx
  pre-mutation blob == HEAD blob : 69e29b8c273a9e3d5b551dd46c2d57a388b92cc1 == 69e29b8c273a9e3d5b551dd46c2d57a388b92cc1
  grep count of injected '(41)' : 1     grep count of removed '(42)' : 0
  post-mutation blob d886ddd254dda246ae735cdb012f6912a622112f differs from HEAD: yes
  EXIT=1
    content/docs/ui/react-pages.mdx:380  [stale]
      author-time rules (41)
      -> prints 41, and the author-time rule count for `validate` derives 42 today
  restore: git diff HEAD -> 0 changed path(s); blob back to 69e29b8c…: yes

ABLATION 2 — a declaration is removed, content/docs/deployment/cli.mdx
  grep count of removed declaration : 0   (bare fences after mutation: 57)
  post-mutation blob differs from HEAD: yes
  EXIT=1
    content/docs/deployment/cli.mdx:456  [undeclared]
      author-time rules (42)
      -> quotes the author-time rule count but declares no transcript kind, so nothing compares it
  restore: blob back to 9e8c1af4…: yes

ABLATION 3 — the built registry is absent (packages/lint/dist moved aside)
  dist present after mutation? no
  EXIT=3
    check-docs-transcript-drift: PREREQUISITE NOT MET — … packages/lint/dist/index.js does not exist
    ⛔ This is NOT a pass and NOT a finding: nothing was measured.
  restore: dist present again: yes

CONTROL — restored tree
  EXIT=0  ✓ 4 declared transcript value(s) across 405 page(s) … equal what the registry derives today
  git status: empty

And the corpus itself was the fourth reading: before the four declarations were added, the gate reddened on all four sites with [undeclared], which is what says the ratchet is not vacuous on the live tree.

7. Derivation before / after

node scripts/pm/dispatch-gates.mjs --commands <path>, on origin/main (d30ccb9) and on this branch (8aecfb8):

change set before after check:docs-transcript-drift
content/docs/ui/react-pages.mdx 37 commands 38 commands absent → present
packages/lint/src/authoring-rules.ts 36 commands 37 commands absent → present

Both sides of the equality this gate pins now derive it.

8. Cost

  • CI minutes: zero added. The step runs in a lane that already builds; the gate itself is one directory walk of 405 .mdx pages plus one dynamic import.
  • Gate wall time, measured locally on a shared box: check:docs-transcript-drift (self-test + production run) ~1.6 s total, of which the production run is ~0.4 s. The self-test spawns three child processes for its program-level battery.
  • Open PRs whose change set newly derives it: any PR touching content/docs/** or packages/lint/**. On this tree that is the same population check:doc-anchors and check:docs-single-h1 already carry, so no PR gains a gate family it did not already have several of.

9. Verification

Exit codes captured before any pipe; the verdict lines are the gates' own.

command exit verdict
pnpm check:docs-transcript-drift 0 ✓ check-docs-transcript-drift self-test: 33 cases pass … / ✓ check-docs-transcript-drift: 4 declared transcript value(s) across 405 page(s) under content/docs/ equal what the registry derives today, and no undeclared block quotes one.
the same gate, dist/ absent 3 PREREQUISITE NOT MET … ⛔ This is NOT a pass and NOT a finding
node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack 0 81 commands derived; every one run below
pnpm check:pm-dispatch-gates (detached, per its header) 0 ✓ dispatch-gates self-test: 1445 cases pass.
node scripts/check-self-test-wired.mjs (+ --self-test) 0, 0 172 of 187 self-test-carrying scripts run by 31 workflows
node scripts/check-ci-filter-parity.mjs 0 green
pnpm check:watch-hint-literal 0 green
pnpm check:declared-population-live 0 green
pnpm check:nul-bytes 0 check-nul-bytes: OK (scanned 7572 text file(s) … no raw ASCII control bytes)
pnpm check:doc-anchors, check:docs-single-h1, check:docs-redirects, check:doc-authoring, check:corpus-claim-drift, check:role-word 0 green
pnpm --filter @objectstack/spec check:docs 0 green
node scripts/check-step-collectors.mjs, check-aggregator-roster.mjs, check-required-contexts.mjs, check-doc-frontmatter.mjs, check-declaration-mirrors.mjs, check-self-test-workflow-commands.mjs (+ self-tests) 0 green
the remaining 60 derived commands 0 green

Not measured, by name, with why:

command code why
pnpm check:dual-build-cjs-loads 3 PREREQUISITE NOT MET — … some package has no dist/ — this container built only @objectstack/lint and its closure, not the workspace. Not a pass and not a finding.
pnpm check:type-check-debt 3 same, same reason.
pnpm --filter @objectstack/spec check:skill-examples 1 packages/client-react/dist holds no .d.ts declarations — the package is not built. Its own refusal-to-guess path, not a finding about this diff.

One derived gate reddened and it is not this diff: pnpm check:docs-audit-scope fails affected-docs.mjs --self-test on this container. Root-caused rather than waved off — the trigger is packages/spec/.examples-build/, a gitignored scratch tree that check:skill-examples leaves behind; hold that directory out and the gate is green (✓ docs-accuracy-audit scope is in sync with content/docs/: 190 hand-written doc(s)). Two controls: a pristine worktree at this exact commit passes ✓ affected-docs self-test: 568 cases pass., and so does the same worktree after pnpm install. That is already filed as #15446 (and #15328 is the neighbouring reading); the hot file it names is not touched here.

Changeset

None — scripts/**, a workflow step and docs declarations publish nothing from any package. skip-changeset applied.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

…the registry it quotes, so a derived count cannot drift silently (#15373)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026
@github-actions github-actions Bot added size/l ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1be26b0de27b2c45d208a20edffefbac1e4892c2packageMentionDocs.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

Reviewed against the three-dot diff at 8aecfb84b (7 files, +842/−4), not the report.

What the diff does, checked line by line

  • New scripts/docs-audit/check-docs-transcript-drift.mjs: fenced blocks under content/docs/** that quote author-time rules (N) must declare transcript=<kind> as fence meta after a language; the gate derives N by importing the BUILT @objectstack/lint through its own manifest entry and calling authoringRulesFor(command).length, exactly the CLI's derivation. Five finding kinds (stale, undeclared, unknown-kind, no-language, declared-nothing), two anti-vacuity refusals (zero pages, zero declarations), exit 3 on a missing dist. Kinds are per command (validate/build/lint), and the header records the measured reason (lint derives 39 today, not 42). The renderer measurement (bare-fence transcript= breaks the docs build; text + meta is byte-identical output) is what justifies the no-language finding.
  • The four pages: opening fence \`````text transcript=os-validate(three) /transcript=os-build (cli.mdx, under ◆ Compile`). Numbers untouched — the 42s stay as PR docs(releases): the 17.2.0 and 17.3.0 upgrade checklists, written from a measured upgrade #15369 left them.
  • package.json: one check:docs-transcript-drift row (--self-test && gate). lint.yml: one step in Type Check · workspace immediately after Build workspace packages, with the placement reasoning inline — the only lane with no paths filter AND a dist.
  • No changeset: nothing published; skip-changeset on the PR.

Measured by this seat on the PR head (throwaway worktree, PM checkout's node_modules, no packages/lint/dist):

node scripts/docs-audit/check-docs-transcript-drift.mjs --self-test   EXIT=0
✓ check-docs-transcript-drift self-test: 33 cases pass (…)
node scripts/docs-audit/check-docs-transcript-drift.mjs               EXIT=3
check-docs-transcript-drift: PREREQUISITE NOT MET — … packages/lint/dist/index.js does not exist

The live gate is NOT MEASURED here (no dist); the exit-3 path behaved as specified. CI's Type Check · workspace lane is the arbiter.

One note, not a blocker: at the entrypoint, a self-test that returns EXIT_FINDINGS after printing its failed cases also trips the selfTestReachedVerdict guard, so a genuinely failing self-test prints the "returned without reaching its verdict" line as well. Exit code is right either way; the extra line is a legibility nit for whoever next touches the file.

Fences held: docs/audits/** untouched (out of population by design); check:corpus-claim-drift not extended; hot-file scan across the other open PRs shows lint.yml/package.json overlap only with other seats' ready PRs on different hunks; git merge-tree origin/main clean.

Flipping ready + enabling auto-merge. Fixes #15373 closes on merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants